Skip to content

Dark Mode

A few years ago, I created a blog post called The Quest for the Perfect Dark Mode (opens in new tab). In that post, I write:

Maybe the hardest / most complicated part of building this blog was adding Dark Mode.

Not the live-embedded code snippets, not the unified GraphQL layer that manages and aggregates all content and data, not the custom analytics system, not the myriad bits of whimsy. Freaking Dark Mode.

It turns out that “Dark Mode” is a surprisingly deep rabbit hole. It seems relatively straightforward, but there's a fundamental issue that makes it tricky to implement correctly.

In this lesson, we'll look at several “dark mode” implementations and discuss the various trade-offs, before landing on the best solution I'm aware of.

Video Summary

The most straightforward “dark mode” implementation is to do it entirely in CSS, using the prefers-color-scheme media query:

html {
--color-text: hsl(0deg 0% 5%);
--color-background: hsl(230deg 20% 95%);
--color-primary: hsl(245deg 100% 50%);
--color-secondary: hsl(345deg 100% 50%);
}
@media (prefers-color-scheme: dark) {
html {
--color-text: hsl(0deg 0% 100%);
--color-background: hsl(230deg 20% 8%);
--color-primary: hsl(50deg 100% 50%);
--color-secondary: hsl(345deg 100% 70%);
}
}

In this implementation, we create a CSS variable for each color in our theme, and hang it on the root html tag.

We then use these variables in our CSS, across the entire application:

button {
background: var(--color-primary);
}

The value of --color-primary depends on the color mode of the user's operating system.

This isn't always what we want. I generally use “light mode” in my system preferences, but I still want individual websites to be dark! Ideally, we want to give our users the ability to toggle between light/dark themes, which just isn't possible with this approach.

Alternatively, we can use React state to manage this for us. First, we'll need to store our colors in JS. I prefer to keep them in a constants.js file, like this:

export const LIGHT_COLORS = {
text: 'hsl(0deg 0% 5%)',
background: 'hsl(230deg 20% 95%)',
primary: 'hsl(245deg 100% 50%)',
secondary: 'hsl(345deg 100% 50%)',
};
export const DARK_COLORS = {
text: 'hsl(0deg 0% 100%)',
background: 'hsl(230deg 20% 8%)',
primary: 'hsl(50deg 100% 50%)',
secondary: 'hsl(210deg 100% 70%)',
};

Here's my initial attempt:

'use client';
function RootLayout({ children }) {
const [theme, setTheme] = React.useState('light');
const COLORS = theme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
function flipTheme() {
setTheme(theme === 'light' ? 'dark' : 'light');
}
return (
<html
data-color-theme={theme}
style={{
'--color-text': COLORS.text,
'--color-background': COLORS.background,
'--color-primary': COLORS.primary,
'--color-secondary': COLORS.secondary,
}}
>
<body>
<DarkLightToggle
theme={theme}
handleClick={flipTheme}
/>
{/* ✂️ The rest of the app here */}
</body>
</html>
);
}

We're keeping track of the user's selected theme in a new state variable, theme. The user can click a button inside DarkLightToggle to flip between light/dark. We then apply our CSS variables to the <html> tag using inline styles.

Because this component uses state, we've had to convert it to a Client Component with "use client". This means that any component owned by RootLayout will also become a Client Component (though fortunately, the page-specific content is passed in through children, and so it can still be a Server Component).

This approach is missing something important. We aren't saving the user's preference. As a result, users who prefer dark mode will have to keep toggling it, on every single visit.

Well, maybe we can use Local Storage for this? Here's one implementation (making sure to avoid server crashes or hydration mismatches):

'use client';
function RootLayout({ children }) {
const [theme, setTheme] = React.useState('light');
React.useEffect(() => {
const savedValue =
window.localStorage.getItem('color-theme');
if (savedValue) {
setTheme(savedValue);
}
}, [])
React.useEffect(() => {
window.localStorage.setItem(
'color-theme',
theme
);
}, [theme]);
// The rest unchanged
}

When this component is first rendered on the server, theme will be light. On the client, after hydration, we check if a preference has been persisted; if so, we update the state.

The second effect persists any theme changes into Local Storage, to set things up for the next visit.

This approach technically works, but it produces a janky flicker:

This happens because there's a significant amount of time between the client receiving the server-generated HTML (which will always be light-theme), and the client re-rendering with the correct theme value.

This is why Dark Mode is such a dastardly problem! We need the theme to be correct from the very first paint.

With static generation (rendering the HTML at build-time), this is a particularly gnarly problem. Each user receives the exact same HTML regardless of their color preference. The only solution I'm familiar with is to inject a small <script> tag into the HTML file that blocks rendering until the locally-stored theme is read, and the DOM is mutated. This is the approach I take in “The Quest for the Perfect Dark Mode” (opens in new tab).

Fortunately, with Next.js, we don't have to stick to the static approach. Instead, we can dynamically generate the HTML on-demand, and use cookies to tailor it for each user.

We haven't really dealt with cookies much in this course. Essentially, they're a way to store small bits of data on the user's device, similar to Local Storage. But, cookies are automatically sent along with each and every HTTP request.

This means we can access their preference while generating the HTML on the server:

import React from 'react';
import { cookies } from 'next/headers';
function RootLayout({ children }) {
const savedTheme = cookies().get('color-theme');
const theme = savedTheme?.value || 'light';
const COLORS =
theme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<html
data-color-theme={theme}
style={{
'--color-text': COLORS.text,
'--color-background': COLORS.background,
'--color-primary': COLORS.primary,
'--color-secondary': COLORS.secondary,
}}
>
<body>
<DarkLightToggle initialTheme={theme} />
{/* ✂️ The rest of the app here */}
</body>
</html>
);
}

cookies is a method, provided by Next.js, which gives us access to the request cookies. The theme variable will either be equal to the previously-saved value, or "light" if no cookie was included.

This method can only be used within Server Components. As a result, we'll deal with the dynamic stuff within DarkLightToggle.

This is where things get a bit icky. Here's what the code looks like:

'use client';
import React from 'react';
import { Sun, Moon } from 'react-feather';
import Cookie from 'js-cookie';
import { LIGHT_COLORS, DARK_COLORS } from '@/constants';
function DarkLightToggle({ initialTheme }) {
const [theme, setTheme] = React.useState(initialTheme);
function handleClick() {
const nextTheme = theme === 'light' ? 'dark' : 'light';
// Update the state variable.
// This causes the Sun/Moon icon to flip.
setTheme(nextTheme);
// Write the cookie for future visits
Cookie.set('color-theme', nextTheme, {
expires: 1000,
});
// Apply the new colors to the root HTML tag.
const COLORS =
nextTheme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
const root = document.documentElement;
root.setAttribute(
'data-color-theme',
nextTheme
);
root.style.setProperty(
'--color-text',
COLORS.text
);
// ✂️ Repeat for all colors
}
return (
<button
className={styles.wrapper}
onClick={handleClick}
>
{theme === 'light' ? (
<Sun size="1.5rem" />
) : (
<Moon size="1.5rem" />
)}
</button>
);
}

When the user clicks the toggle, we do several things.

First, we update the state variable. This ensures we're rendering the correct icon.

Next, we update the color-theme cookie, for subsequent visits. Unlike Local Storage, the built-in API for working with cookies is a bit painful, and so we typically use a library for this. I'm using js-cookie, but there's nothing particularly special about it; any cookie-manipulating library will work.

Finally — and this is the gross part — we edit the attributes on the root HTML tag, swapping out the data attribute (which identifies the current theme) and all of the inline styles.

It's generally bad form for one component to edit the markup owned by another component. In this case, the <html> tag is owned by the RootLayout component.

But, RootLayout is a Server Component, and it has to be, in order to use the cookies() method. And, in this wild new RSC world, the old rules don't necessarily apply.

The reason it's bad to “reach in” and modify the markup owned by another component is that it means that a single DOM node can be modified in multiple places. But if a DOM node was created by a Server Component, it isn't really being actively managed.

It still feels a bit funny to me, but ultimately, I don't have any better ideas. With this approach, the UX is perfect: everything works properly, there's no flicker, the performance is good. The biggest implication is that it means every route will wind up being dynamically rendered, rather than being statically-generated. But in a world with Streaming SSR + Suspense, I don't think that's a terrible thing.

The other not-ideal thing about this implementation is that I need to edit 3 separate files whenever I want to add a new color:

  1. The constants.js file, to create the color itself
  2. The layout.js file, to attach that color to the initial server-generated HTML
  3. The DarkLightToggle.js component, to update the color when the user clicks the toggle

Fortunately, this is an solvable problem. Below this video, you'll find a link to a more-polished solution, which adds a theme-helpers.js file. In that solution, you only have to add the color to constants.js. Everything else happens automatically.

Who knew that Dark Mode could be so much trouble? 😅

You can view the complete solution — including the optimizations I promised — on Github:

Here are the highlights:

// src/constants.js
/*
I updated the keys on these theme objects to be formatted
as CSS variables. Honestly this is something I should've done
from the start, it makes things much simpler! 😅
*/
export const LIGHT_COLORS = {
'--color-text': 'hsl(0deg 0% 5%)',
'--color-background': 'hsl(230deg 20% 95%)',
'--color-primary': 'hsl(245deg 100% 50%)',
'--color-secondary': 'hsl(345deg 100% 50%)',
};
export const DARK_COLORS = {
'--color-text': 'hsl(0deg 0% 100%)',
'--color-background': 'hsl(230deg 20% 8%)',
'--color-primary': 'hsl(50deg 100% 50%)',
'--color-secondary': 'hsl(210deg 100% 70%)',
};

Inside the root layout.js, I apply whichever theme object is currently applicable:

// src/app/layout.js
async function RootLayout({ children }) {
const savedTheme = (await cookies()).get('color-theme');
const theme = savedTheme?.value || 'light';
const themeColors = theme === 'light'
? LIGHT_COLORS
: DARK_COLORS;
return (
<html
lang="en"
data-color-theme={theme}
style={themeColors}
>
{/* Inner content unchanged */}
</html>
);
}

Inside DarkLightToggle, I iterate over the key/value pairs in the theme object and overwrite the value for the CSS variable:

function handleClick() {
const nextTheme = theme === 'light' ? 'dark' : 'light';
setTheme(nextTheme);
Cookie.set('color-theme', nextTheme, {
expires: 1000,
});
const root = document.documentElement;
const colors = nextTheme === 'light' ? LIGHT_COLORS : DARK_COLORS;
root.setAttribute('data-color-theme', nextTheme);
Object.entries(colors).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
}